Skip to content

fix(workflows): reject a condition that has no {{ }} block - #4182

Merged
mnriem merged 6 commits into
github:mainfrom
ntdatt812:fix/condition-without-expression-block
Aug 20, 2026
Merged

fix(workflows): reject a condition that has no {{ }} block#4182
mnriem merged 6 commits into
github:mainfrom
ntdatt812:fix/condition-without-expression-block

Conversation

@ntdatt812

Copy link
Copy Markdown
Contributor

Defect

evaluate_condition resolves its argument through evaluate_expression, which only substitutes {{ ... }} blocks. A string with no such block comes back unchanged, and — unless it reads true/false — is then coerced by bool(). So a condition authored without the braces is never evaluated at all.

With inputs.count == 5:

evaluate_condition("{{ inputs.count > 100 }}", ctx)   # False  — correct
evaluate_condition("inputs.count > 100", ctx)         # True   — never evaluated
evaluate_condition("inputs.name == 'zzz'", ctx)       # True
evaluate_condition("inputs.count < 3", ctx)           # True

Every brace-less condition is true, whatever it says. An if step always takes then; a while/do-while step never terminates on its condition and runs to max_iterations — ten agent invocations for a loop the author expected to stop after one.

Nothing reports it. The workflow validates, runs, and takes the wrong branch silently.

Why this is worth a validation error

The three step validators already reject a list/dict/number condition, and the comment there states the reason exactly:

a list/dict/number condition silently resolves to its truthiness (e.g. condition: [1, 2] is always True) with no error, branching wrongly on an authoring mistake. Reject those at validation […]

A brace-less string is the same failure mode, and a likelier mistake: GitHub Actions accepts a bare expression in if: (if: github.event_name == 'push'), so an author arriving from Actions writes the brace-less form by habit — and unlike [1, 2], condition: inputs.count > 100 looks completely correct on the page.

Fix

condition_is_never_evaluated() in expressions.py, wired into the if, while and do-while validators. The error names the problem and hands back the corrected form:

If step 's1': 'condition' 'inputs.count > 100' has no '{{ }}' block, so it is
never evaluated and is always true. Wrap the expression: "{{ inputs.count > 100 }}".

Validation only — no runtime behaviour changes. Still valid, and covered by tests: "{{ ... }}" in any position, "true"/"false" in any case, real bools, empty and whitespace strings, and every non-string type (already handled by the branch above).

Tests

New tests/unit/test_condition_expression_block.py, 40 cases:

  • the runtime behaviour is pinned first — same expression with and without braces, asserting False vs True — so the defect stays documented even if the validator changes
  • each of the three step validators rejects the brace-less form and echoes the corrected expression
  • no false positives, parametrised across all three step types
  • the helper itself across strings, bools, containers and numbers
tests/unit + tests/test_workflows.py

before:  22 failed, 1083 passed, 9 skipped
after:   22 failed, 1123 passed, 9 skipped

The same 22 pre-existing failures in both runs — all symlink_to on Windows without the privilege (OSError: [WinError 1314]), unrelated to this change. Everything added is the +40 new tests.

`evaluate_condition` resolves its argument through `evaluate_expression`,
which only substitutes `{{ ... }}` blocks. A string with no such block
comes back unchanged and — unless it reads `true`/`false` — is then
coerced by `bool()`. So a condition authored without the braces is never
evaluated at all:

    evaluate_condition("inputs.count > 100", ctx)      -> True
    evaluate_condition("{{ inputs.count > 100 }}", ctx) -> False

with `inputs.count == 5` in both cases. An `if` step always takes `then`,
and a `while`/`do-while` step always runs to `max_iterations` — ten agent
invocations for a loop the author expected to stop.

This is the same silent-truthiness authoring mistake the three step
validators already reject for a list/dict/number condition, and it is
easier to make: GitHub Actions accepts a bare expression in `if:`, so the
brace-less form is a habit to bring here.

Adds `condition_is_never_evaluated()` and wires it into the `if`,
`while` and `do-while` validators, so the mistake surfaces at validation
with the corrected form spelled out. Boolean literals, real bools, empty
strings and any string containing `{{` stay valid — runtime behaviour is
unchanged.
@ntdatt812
ntdatt812 requested a review from mnriem as a code owner August 18, 2026 10:04
@mnriem
mnriem requested a balanced review from Copilot August 18, 2026 12:25

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds validation to reject brace-less workflow conditions that would otherwise always evaluate as true.

Changes:

  • Adds a shared condition-validation helper.
  • Integrates validation into if, while, and do-while steps.
  • Adds runtime and validator regression tests.
Show a summary per file
File Description
src/specify_cli/workflows/expressions.py Adds condition detection helper.
src/specify_cli/workflows/steps/if_then/__init__.py Validates if conditions.
src/specify_cli/workflows/steps/while_loop/__init__.py Validates while conditions.
src/specify_cli/workflows/steps/do_while/__init__.py Validates do-while conditions.
tests/unit/test_condition_expression_block.py Adds regression coverage.

Review details

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

  • Files reviewed: 5/5 changed files
  • Comments generated: 4
  • Review effort level: Balanced

Comment thread src/specify_cli/workflows/expressions.py Outdated
Comment thread src/specify_cli/workflows/steps/if_then/__init__.py Outdated
Comment thread src/specify_cli/workflows/steps/while_loop/__init__.py Outdated
Comment thread src/specify_cli/workflows/steps/do_while/__init__.py Outdated
Two gaps in the condition validator, both raised in review.

An opening `{{` with no `}}` after it is never substituted either:
_interpolate_expressions takes its `raw_close == -1` branch and appends
the tail verbatim. So `condition: "{{ inputs.count > 100"` -- and the
reversed `"}} inputs.count > 100 {{"`, whose only `{{` is last -- come
back unchanged and are coerced to true exactly like a brace-less string.
The helper now looks for a complete block rather than an opening one.

The suggested correction was interpolated into a double-quoted scalar,
so a condition containing a double quote produced YAML that does not
parse: `condition: "{{ inputs.name == "zzz" }}"` raises a ParserError.
format_condition_correction() now picks the quoting from the content and
drops a stray delimiter instead of nesting a second one, so the message
stays paste-ready. All three validators share it.

Tests: 30 more cases -- the incomplete forms, and a YAML round trip over
conditions holding single quotes, double quotes, both, and backslashes,
asserting each correction loads back exactly and is not re-flagged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@ntdatt812

Copy link
Copy Markdown
Contributor Author

Both review points were correct — thanks. Fixed in c0c291c.

1. Unterminated / reversed delimiters slipped through

Confirmed against the interpolator rather than assumed. _interpolate_expressions() takes its raw_close == -1 branch and appends the tail verbatim, so nothing is substituted:

condition evaluate_condition old helper new helper
inputs.count > 100 True flags flags
{{ inputs.count > 100 True misses flags
}} inputs.count > 100 {{ True misses flags
{{ inputs.count > 100 }} False

The helper now requires a complete block: it finds the first {{ and checks a }} follows it. {{ a }} {{ b }} and {{ inputs.text | default('}}') }} stay unflagged.

2. The correction was not valid YAML

Reproduced exactly as described — condition: "{{ inputs.name == "zzz" }}" raises yaml.parser.ParserError. A remediation the author cannot paste is not a remediation.

Added format_condition_correction() in expressions.py, used by all three validators as suggested. It picks quoting from the content — double by default, single when the expression holds a double quote, double with backslash escapes when it holds both — and drops a stray delimiter instead of nesting a second one, so {{ inputs.count > 100 corrects to "{{ inputs.count > 100 }}" rather than "{{ {{ ... }} }}".

condition emitted correction
inputs.name == "zzz" '{{ inputs.name == "zzz" }}'
inputs.name == 'zzz' "{{ inputs.name == 'zzz' }}"
inputs.a == "x" and inputs.b == 'y' "{{ inputs.a == \"x\" and inputs.b == 'y' }}"
inputs.path == 'C:\tmp' "{{ inputs.path == 'C:\tmp' }}"

Tests — the file goes from 40 to 70 cases. The new ones cover the incomplete forms, and run every correction through yaml.safe_load asserting it loads back to exactly {{ <expr> }} and that the corrected form is not itself re-flagged — the invariant that would have caught both of these.

uvx ruff@0.15.0 check src tests clean; pytest tests/unit 250 passed (the 2 symlink failures are a local Windows privilege limitation, green on CI).

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review details

  • Files reviewed: 5/5 changed files
  • Comments generated: 2
  • Review effort level: Balanced

Comment thread src/specify_cli/workflows/expressions.py Outdated
Comment thread src/specify_cli/workflows/expressions.py Outdated

@mnriem mnriem left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please address Copilot feedback

…h json.dumps

Both follow-up review points were right.

The completeness check used a plain `find("}}")`, but the substituter closes a
block with a quote-aware scan. So `condition: "{{ inputs.x == '}}'"` looked
complete to the validator while `_interpolate_expressions` found no close, fell
to its raw-close branch, evaluated a truncated body and left residual text
(`False'`) -- a non-empty string, hence true. Rather than restate the quote
rules a third time, the scan moves out of `_interpolate_expressions` into
`_find_block_close`, which the validator now calls: the check and the
substitution it predicts can no longer disagree. A `}}` that is genuinely
inside a string argument still does not close early, so
`{{ inputs.text | default('}}') }}` and `{{ inputs.x == '}}' }}` stay accepted.

The correction's quoting enumerated the characters it escaped, and the
enumeration was short: a condition loaded from a YAML literal block can carry a
newline, which a double-quoted scalar folds, so the corrected form did not
round-trip. `json.dumps` decides it instead -- every JSON string is a valid
YAML double-quoted scalar and it escapes quotes, backslashes, newlines and the
other control characters. `ensure_ascii=False` keeps a non-ASCII operand
readable rather than expanding it into numeric escapes.

Tests: 70 -> 83. The quoted-delimiter condition joins the incomplete-block set,
and the round-trip set gains multiline, newline-with-quote, tab, carriage
return and non-ASCII operands. All four new cases fail on the previous commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@ntdatt812

Copy link
Copy Markdown
Contributor Author

@mnriem — both follow-up findings addressed in dc3c7de. Reproduced each one first rather than taking them on trust; both were real.

1. The }} check was not quote-aware

Confirmed exactly as described:

condition evaluate_condition validator, before after
{{ inputs.x == '}}' True misses it flags
{{ inputs.x == '}}' }} False not flagged
{{ inputs.text | default('}}') }} not flagged not flagged

_interpolate_expressions finds no quote-aware close, falls to its raw-close branch, evaluates a truncated body and leaves residual text (False') — non-empty, so bool() makes it true.

Rather than restate the quote rules a third time in this module, I lifted the scan out of _interpolate_expressions into _find_block_close() and had the validator call it. The check and the substitution it is predicting now share one implementation and cannot drift — which is the same failure mode this PR is about. _interpolate_expressions keeps its exact previous behaviour; the extraction is behaviour-preserving.

2. Hand-rolled quoting missed control characters

Also reproduced — a condition carrying a newline did not round-trip:

condition before after
inputs.x == 1\nand inputs.name == 'abc' newline folded, round-trip fails exact
he said "hi"\nthen left ParserError exact
inputs.a == 'x\ry' round-trip fails exact

Switched to json.dumps(..., ensure_ascii=False) as suggested — json was already imported, every JSON string is a valid YAML double-quoted scalar, and it removes the escape enumeration entirely. ensure_ascii=False keeps a non-ASCII operand readable instead of expanding it into \uXXXX; there is a test for that.

Tests 70 → 83. The quoted-delimiter condition joins the incomplete-block set; the round-trip set gains multiline, newline-with-quote, tab, carriage return and a non-ASCII operand. All four new cases fail on the previous commit (4 failed, 79 passed) and pass here (83 passed) — they are not vacuous.

Verification on this head: uvx ruff@0.15.0 check src tests — All checks passed. pytest tests/unit — 263 passed. pytest tests/workflows — 179 passed. The handful of local failures in both directories are the Windows symlink-privilege cases, identical on unpatched main; the CI matrix is the authority there and was green on the previous head.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review details

Suppressed comments (2)

Previously missed (1) — in code that hasn't changed since the last review.

src/specify_cli/workflows/expressions.py:730

  • Non-empty whitespace is not false at runtime. evaluate_condition() strips only while checking the true/false keywords and then falls through to bool(result), so bool(" ") is True. Excluding it here lets every validator accept a condition that silently takes then or loops to max_iterations, exactly the failure this helper is intended to reject. Please distinguish the truly empty string from non-empty whitespace (and update the tests that currently mark whitespace valid), or normalize whitespace in the runtime evaluator.

This issue also appears on line 765 of the same file.

    stripped = condition.strip()
    if not stripped or stripped.lower() in ("true", "false"):
        return False

src/specify_cli/workflows/expressions.py:768

  • The correction only removes delimiters at the edges, but the validator also flags malformed delimiters in the middle. For example, prefix {{ inputs.ready is flagged and corrected to "{{ prefix {{ inputs.ready }}"; likewise inputs.ready }} suffix retains the inner }}. These suggestions do not evaluate the intended condition and may subsequently evade this validator because they contain a complete outer/first block. Please either sanitize stray delimiters outside quoted operands or avoid offering an automatic correction for malformed-block cases.
    core = str(condition).strip()
    core = re.sub(r"^\s*(\{\{|\}\})\s*", "", core)
    core = re.sub(r"\s*(\{\{|\}\})\s*$", "", core).strip()
    return json.dumps("{{ " + core + " }}", ensure_ascii=False)
  • Files reviewed: 5/5 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

…nesting a block

Two review findings, both reproduced against the code before changing it.

**1. Non-empty whitespace was excluded, and it should not have been.**

The docstring claimed a whitespace condition "coerces to False, which is a
definite answer". That is true only of the empty string. Measured:

    evaluate_condition("")      -> False
    evaluate_condition("   ")   -> True
    evaluate_condition("\t\n ") -> True

`evaluate_condition` strips only while testing the true/false keywords, then
falls through to `bool()` on the raw string -- and
`test_condition_whitespace_only_string_stays_truthy` pins that on purpose. So
`condition: "   "` is exactly the silent always-true this helper exists to
catch, and it was sailing through. Fixed at validation time rather than in the
evaluator, because that runtime behaviour is deliberate.

The empty string stays excluded: it really does coerce to False.

**2. The correction only removed edge delimiters, so it could nest one.**

    "prefix {{ inputs.ready"  ->  "{{ prefix {{ inputs.ready }}"

The suggestion carried an unclosed inner block, and because its *outer* block
was complete, `condition_is_never_evaluated` waved the corrected form straight
back through. Same for a trailing `}}`.

`_strip_stray_delimiters` now removes every delimiter, and is quote-aware for
the reason the rest of this module is: `inputs.x == '}}'` holds a delimiter as
data, and a blanket `re.sub` would eat it and change what the condition
compares. `_find_top_level` could not be reused -- it counts `{`/`}` as bracket
depth, so it never reports a `{{` as a token at all.

    "prefix {{ inputs.ready"   -> "{{ prefix inputs.ready }}"
    "inputs.ready }} suffix"   -> "{{ inputs.ready suffix }}"
    "{{ inputs.x == '}}'"      -> "{{ inputs.x == '}}' }}"      (data kept)
    '{{ inputs.name == "a  b"' -> '{{ inputs.name == "a  b" }}' (spacing kept)

Whitespace collapses only where a delimiter was removed; inside a quoted
operand it is untouched.

Tests: the two fixtures that asserted whitespace was valid are corrected, and
five cases added for interior delimiters, quoted delimiters and quoted spacing.
87 pass in tests/unit/test_condition_expression_block.py.

tests/test_workflows.py is 20 failed / 903 passed both with and without this
change -- all twenty are symlink tests that need Windows Developer Mode, and
the counts are identical with the diff stashed.
@ntdatt812

Copy link
Copy Markdown
Contributor Author

Both suppressed findings in the latest Copilot pass are real. Fixed in aa9434a, and I reproduced each one against the committed code before touching it rather than taking them on trust.

1. Non-empty whitespace was excluded, and it should not have been

My docstring claimed a whitespace condition "coerces to False, which is a definite answer". That holds only for the empty string:

evaluate_condition("")      -> False
evaluate_condition("   ")   -> True
evaluate_condition("\t\n ") -> True

evaluate_condition strips only while testing the true/false keywords and then falls through to bool() on the raw string — and this repo pins that deliberately in test_condition_whitespace_only_string_stays_truthy. So condition: " " is precisely the silent always-true this helper exists to catch, and it was walking past the validator.

That existing test is why I fixed it at validation time rather than normalizing in the evaluator, which was Copilot's alternative: normalizing would contradict a behaviour the suite intentionally holds. The empty string stays excluded, because it really does coerce to False.

2. The correction removed only edge delimiters, so it could nest one

"prefix {{ inputs.ready"  ->  "{{ prefix {{ inputs.ready }}"

The suggestion carried an unclosed inner block, and because the outer block was complete, condition_is_never_evaluated accepted the corrected form — the validator waving through its own bad advice. Same shape with a trailing }}.

_strip_stray_delimiters now removes every delimiter and is quote-aware, for the reason the rest of this module is: inputs.x == '}}' holds a delimiter as data, and the blanket re.sub Copilot's first phrasing suggests would eat it and silently change what the condition compares against. I could not reuse _find_top_level — it counts { and } as bracket depth, so it never reports a {{ as a top-level token at all.

"prefix {{ inputs.ready"    -> "{{ prefix inputs.ready }}"
"inputs.ready }} suffix"    -> "{{ inputs.ready suffix }}"
"{{ inputs.x == '}}'"       -> "{{ inputs.x == '}}' }}"        <- delimiter kept as data
'{{ inputs.name == "a  b"'  -> '{{ inputs.name == "a  b" }}'   <- inner spacing kept

Whitespace collapses only where a delimiter was removed; inside a quoted operand nothing is touched.

One consequence I would rather name than leave for you to find. For a whitespace-only condition there is nothing to wrap, so the message ends Wrap the expression: "{{ }}". The diagnosis is right and the suggestion is empty. If you would rather the three validators omit the correction clause when there is no content to wrap, say so and I will thread that through — I left it alone because it means editing the message construction in if_then, while_loop and do_while, and I did not want to widen the diff while changes are requested.

Verification. tests/unit/test_condition_expression_block.py: 87 passed. The two fixtures that asserted whitespace was valid are corrected, and five cases added for interior delimiters, quoted delimiters and quoted spacing.

tests/test_workflows.py is 20 failed / 903 passed with this change, and 20 failed / 903 passed with it stashed — identical. All twenty are symlink tests that need Windows Developer Mode. Repo-wide the run is 168 failed / 6775 passed, and none of the 168 touches conditions or expressions; the same environment limit accounts for them.

@mnriem — this is on top of the two findings you raised, which went in as dc3c7de. As an outside contributor I cannot re-request review, so this comment is the only signal I have.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review details

Suppressed comments (1)

Previously missed (1) — in code that hasn't changed since the last review.

src/specify_cli/workflows/expressions.py:752

  • This does not always mean the condition is “never evaluated.” _interpolate_expressions falls back to the first raw }} when the quote-aware scan fails and evaluates the truncated body. For example, {{ inputs.missing | default('oops }} reaches _apply_filter and raises ValueError, while this helper returns True, causing all three validators to report that it is always true. Please distinguish genuinely uninterpolated text from malformed blocks that take the raw-close evaluation path, and report the latter as malformed rather than always true.
    return _find_block_close(stripped, open_at) == -1
  • Files reviewed: 5/5 changed files
  • Comments generated: 1
  • Review effort level: Balanced

Comment thread src/specify_cli/workflows/expressions.py Outdated
…luated

Third review finding, and like the first two it reproduces. `condition_is_never_evaluated`
returned True for any `{{` the quote-aware scan could not close -- but
`_interpolate_expressions` does not treat those alike. Its own comment spells out
two sub-cases, and only one is "never evaluated":

  * no raw `}}` in the tail -> the text is emitted verbatim, so bool() makes it
    true. Genuinely uninterpolated.
  * a raw `}}` further along -> that is used as the close and the truncated body
    *is* evaluated.

Measured:

    {{ inputs.count > 100                     -> True            (never evaluated)
    }} inputs.count > 100 {{                  -> True            (never evaluated)
    {{ inputs.x == '}}'                       -> True            (raw-close path)
    {{ inputs.missing | default('oops }}      -> raises ValueError

That last one made the old message wrong on both halves: it is evaluated, and it
does not end up true -- it ends the run in `_apply_filter`.

Adds `condition_has_malformed_expression_block` and gives it its own branch in the
three validators, because the two faults need opposite advice: one says "you forgot
the braces", the other says "your delimiters or quotes do not balance". The two
predicates are mutually exclusive, pinned by a test over every fixture.

The malformed branch deliberately offers **no** paste-ready correction. The fault is
unbalanced quoting, so the quote-aware stripper cannot tell operand from delimiter --
for `{{ inputs.missing | default('oops }}` it emits `"{{ inputs.missing | default('oops }} }}"`,
which is not a fix. This is the same "avoid offering an automatic correction for
malformed-block cases" the reviewer raised earlier; it applies exactly here.

Also renders a blank correction as `"{{ }}"` rather than the double-spaced `"{{  }}"`
that concatenation produced for a whitespace-only condition.

106 pass in tests/unit/test_condition_expression_block.py. Across
tests/test_workflows.py + tests/unit the run is 22 failed / 1189 passed, and 22
failed / 1170 passed with this diff stashed -- identical failures, all Windows
symlink cases, none touching conditions or expressions.
@ntdatt812

Copy link
Copy Markdown
Contributor Author

Third finding, and it holds too. Fixed in 3e28b32, reproduced before changing anything.

condition_is_never_evaluated was returning True for any {{ the quote-aware scan could not close — but _interpolate_expressions does not treat those alike, and its own comment says so. Two sub-cases, only one of which is "never evaluated":

{{ inputs.count > 100                  -> True             no raw '}}': emitted verbatim
}} inputs.count > 100 {{               -> True             same
{{ inputs.x == '}}'                    -> True             raw-close path, residual text
{{ inputs.missing | default('oops }}   -> raises ValueError

That last one made the message wrong on both halves: it is evaluated, and it does not end up true — it ends the run in _apply_filter.

Added condition_has_malformed_expression_block with its own branch in the three validators, because the two faults want opposite advice: one says you forgot the braces, the other says your delimiters or quotes do not balance. The predicates are mutually exclusive, pinned by a test over every fixture rather than left as an assumption.

The malformed branch deliberately offers no correction, which closes the loop on your earlier point about not auto-correcting malformed-block cases — it applies exactly here. The fault is unbalanced quoting, so the quote-aware stripper cannot tell operand from delimiter: for {{ inputs.missing | default('oops }} it produces

"{{ inputs.missing | default('oops }} }}"

which is not a fix. Naming the fault beats handing back something that looks authoritative and is not.

That also resolves the loose end I flagged last round: a blank correction now renders "{{ }}" instead of the double-spaced "{{ }}", so the whitespace case reads properly without needing to restructure the message.

Verification. 106 pass in tests/unit/test_condition_expression_block.py. Across tests/test_workflows.py + tests/unit: 22 failed / 1189 passed with this change, and 22 failed / 1170 passed with the diff stashed — the same 22, all Windows symlink cases needing Developer Mode, none touching conditions or expressions. The passed delta is exactly the tests added here.

@mnriem — that is three review rounds of findings now addressed (c0c291c, dc3c7de, aa9434a, 3e28b32). I still cannot re-request review as an outside contributor, so this comment is the only signal available to me.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review details

Suppressed comments (1)

src/specify_cli/workflows/expressions.py:787

  • Only the first expression block is inspected. A condition such as {{ inputs.name }} {{ inputs.missing | default('oops }} is accepted because the first block closes, although the later malformed block raises ValueError at runtime (the behavior is already pinned in tests/test_workflows.py:301-319). Scan every opener so malformed later blocks fail validation too.
    open_at = stripped.find("{{")
    if open_at == -1:
        return False
    if _find_block_close(stripped, open_at) != -1:
        return False
    return stripped.find("}}", open_at + 2) != -1
  • Files reviewed: 5/5 changed files
  • Comments generated: 1
  • Review effort level: Balanced

Comment thread src/specify_cli/workflows/expressions.py Outdated
Both condition validators stopped at the first `{{`. A condition whose first
block closes was accepted regardless of what followed, so a later unterminated
block escaped validation entirely — the case Copilot raised:

    {{ true }} and {{ inputs.ready    -> both validators returned False

Interpolation leaves `and {{ inputs.ready` in the result and bool() makes the
condition always true, which is exactly the silent-branching defect these
validators exist to catch. The same hole applied to the malformed class:

    {{ inputs.name }} {{ inputs.missing | default('oops }}   -> raises at run time

Add `_first_unclosable_block`, which walks blocks the way
`_interpolate_expressions` does — continuing past each block that closes — and
reports how the first unclosable one will fail: `evaluated` when a raw `}}`
follows (the fallback truncates and evaluates), `verbatim` when none does.
Both validators now read from it, so they cannot disagree with the substitution
they predict.

Two wording fixes fall out of scanning further:

- The never-evaluated message said the condition "has no complete '{{ }}'
  block". With an earlier complete block that is false, so it now says the
  condition "is not a single complete '{{ }}' block".
- `condition_has_malformed_expression_block`'s docstring said the truncated body
  raises ValueError. It does for `default('oops`, but `{{ inputs.x == '}}'`
  evaluates to the residual `"False'"` instead. Measured both; the docstring now
  says either can happen and the error message never claimed otherwise.

Verified on Python 3.11:
- tests/unit/test_condition_expression_block.py  116 passed (was 106)
- tests/unit + tests/test_workflows.py  1199 passed (was 1189), 22 failed
  before and after — all pre-existing symlink tests that need Windows elevation.

Mutation-checked: restoring the stop-after-first-block behaviour fails exactly
the 10 new parametrised cases and nothing else.
@ntdatt812

Copy link
Copy Markdown
Contributor Author

@mnriem — the remaining Copilot finding is addressed in 183b633. It was correct.

The hole. Both validators stopped at the first {{, so a condition whose first block closes was accepted regardless of what followed. Measured against the code as it stood:

condition never_evaluated malformed what interpolation actually does
{{ true }} and {{ inputs.ready False False leaves and {{ inputs.ready, bool() → always true
{{ inputs.name }} {{ inputs.missing | default('oops }} False False raises ValueError (already pinned by test_multi_expression_unbalanced_quote_still_raises)

Both slipped through — the first is the silent-branching defect this PR exists to catch.

The fix. A shared _first_unclosable_block walks blocks exactly the way _interpolate_expressions does, continuing past each one that closes, and reports how the first unclosable block will fail: "evaluated" when a raw }} follows, "verbatim" when none does. Both validators read from it, so they cannot disagree with the substitution they are predicting — the same reason _find_block_close was already shared.

Two wording fixes fell out of scanning further, and I want to flag the second because I had it wrong:

  1. The never-evaluated message said the condition "has no complete {{ }} block". Once an earlier complete block is possible that is false, so it now reads "is not a single complete {{ }} block".

  2. condition_has_malformed_expression_block's docstring claimed the truncated body raises ValueError at run time. I checked both members of that class rather than assume:

    {{ inputs.missing | default('oops }}   -> ValueError
    {{ inputs.x == '}}'                    -> no raise; evaluates to "False'", truthy
    

    So the docstring over-claimed. The user-facing message never did — it says the interpolator "evaluates a truncated expression instead of the one written", which holds either way — but the docstring now says so too.

Verification on Python 3.11:

tests/unit/test_condition_expression_block.py      116 passed  (was 106)
tests/unit + tests/test_workflows.py              1199 passed  (was 1189)

The 22 failures in that second run are identical before and after this commit — all TestWorkflowCliAlignment symlink tests, which need elevation on Windows. I ran the suite on the stashed tree to confirm that rather than assume it.

Mutation-checked: restoring the stop-after-first-block behaviour fails exactly the 10 new parametrised cases and nothing else, so the new fixtures pin this specific defect rather than passing incidentally.

The other Copilot threads on this PR are marked outdated — they were addressed in dc3c7de, aa9434a and 3e28b32 (quote-aware scan shared with the evaluator, json.dumps quoting for the correction, whitespace-only special case, malformed-vs-never-evaluated split). Happy to walk through any of them if it is easier than reading back through the thread.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review details

Suppressed comments (1)

Previously missed (1) — in code that hasn't changed since the last review.

src/specify_cli/workflows/expressions.py:880

  • format_condition_correction is also called for inputs that cannot be fixed by adding delimiters. For example, whitespace becomes "{{ }}", while {{ inputs.name == 'abc becomes "{{ inputs.name == 'abc }}" with the quote still unbalanced. Both are presented as paste-ready corrections even though there is no valid expression to wrap. Detect an empty core or unbalanced quotes and report a tailored validation error without Wrap the expression instead.
    core = _strip_stray_delimiters(str(condition)).strip()
    # A blank core has nothing to wrap; render the empty block rather than the
    # double-spaced "{{  }}" that string concatenation would otherwise produce.
    body = "{{ " + core + " }}" if core else "{{ }}"
  • Files reviewed: 5/5 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

@mnriem
mnriem self-requested a review August 20, 2026 13:23
@mnriem
mnriem merged commit 145e5e6 into github:main Aug 20, 2026
14 checks passed
@mnriem

mnriem commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator

Thank you!

@ntdatt812

Copy link
Copy Markdown
Contributor Author

The suppressed finding on format_condition_correction was also correct — addressed in 2769ce3.

I measured what pasting each suggested correction actually does, rather than reasoning about it:

condition validator says wrapped form it suggested pasting it gives
" " always true "{{ }}" False
{{ inputs.name == 'abc always true "{{ inputs.name == 'abc }}" False

Both suggestions silently invert the condition. The blank core interpolates to the empty string; the open quote survives wrapping, so the raw-close fallback evaluates a truncated comparison whose result is the string "False", which evaluate_condition then reads as the false keyword. Either way the author pastes a "fix" and gets a different defect.

The fix. format_condition_remediation now builds the advice, and the three step validators call it instead of hand-assembling the sentence. It offers a correction only when wrapping would repair the input, and otherwise names the fault — the same call already made for condition_has_malformed_expression_block, which deliberately offers none.

'   '                    -> ... There is no expression here to wrap: use the literal
                             true or false, since an empty '{{ }}' block evaluates to
                             the empty string and would silently invert the condition.

{{ inputs.name == 'abc   -> ... Close the unbalanced quote first: wrapping it as
                             written leaves the quote open, so the raw-close fallback
                             evaluates a truncated comparison rather than the one
                             written, and its result can silently invert the condition.

inputs.count > 100       -> ... Wrap the expression: "{{ inputs.count > 100 }}".

_has_unbalanced_quote uses the same left-to-right scan as _find_block_close and _strip_stray_delimiters, so "inside a string" means one thing throughout the module.

One correction to my own work, since it happened in this commit. My first draft of the unbalanced-quote message said the wrapped form "stays always true". The new test failed and showed it returns Falseevaluate_condition recognises the residual "False" as the keyword before bool() ever sees it. The message and docstring now say inverted, which is what the measurement shows.

Verification on Python 3.11:

tests/unit/test_condition_expression_block.py      133 passed  (was 116)
tests/unit + tests/test_workflows.py              1216 passed  (was 1199)

The 22 failures in the wider run are identical before and after — the TestWorkflowCliAlignment symlink tests, which need elevation on Windows. Confirmed by running the suite on the stashed tree, not assumed.

Mutation-checked: removing either gate fails exactly the 9 new parametrised cases and nothing else.

That clears every Copilot thread on this PR, including the suppressed one. @mnriem — ready for another look whenever suits.

@ntdatt812

Copy link
Copy Markdown
Contributor Author

Correction to my comment above: this merged at 13:24:16Z and I pushed 2769ce3 at 13:25:29Z, so the "ready for another look" was about a minute stale and that commit is not in the merge. My mistake — I checked mergeStateStatus and not state.

What did land in 145e5e688 is everything through 183b633ce, including the every-block scan. The remaining fix — not offering a correction that inverts the condition — is now #4230 against main, with the same verification and mutation check.

Thanks for the merge.

ira-at-work added a commit to ira-at-work/spec-kit that referenced this pull request Aug 21, 2026
…065-stable-block-identifiers

* 'main' of https://github.com/github/spec-kit: (38 commits)
  [extension] Update Security Review extension to v2.0.0 (github#4223)
  fix(presets): reject duplicate provides.templates name+type entries (github#4191)
  fix(bundler): decode a downloaded (non-zip) bundle manifest as UTF-8 (github#4190)
  Update Intake Sequencing Governance preset to v0.2.3 (github#4235)
  Update MAQA — Multi-Agent & Quality Assurance extension to v0.1.6 (github#4234)
  [bug-fix] Fix qodercli-skills-migration: migrate QodercliIntegration to SkillsIntegration (github#4205)
  [preset] Add Inventory Alignment preset to community catalog (github#4229)
  [extension] Add Spec Inventory extension to community catalog (github#4228)
  [extension] Update Architecture Guard extension to v2.3.6 (github#4224)
  Update SpecKit Companion extension to v0.20.2 (github#4225)
  fix(workflows): reject a condition that has no {{ }} block (github#4182)
  fix: raise feature assessment credit budget (github#4222)
  [extension] Add AgentDocx extension to community catalog (github#4184)
  fix(integrations): report a falsy non-mapping integration descriptor as a shape error (github#4187)
  Update Autonomous Run Governance preset to v0.4.1 (github#4203)
  fix(workflows): validate dispatch defaults (github#4181)
  Update Atlas extension display name in community catalog (github#4202)
  Add Closed Vocabulary Check preset to community catalog (github#4201)
  fix(utils): narrow bare except Exception in merge_json_files (github#4189)
  chore: release 0.16.5, begin 0.16.6.dev0 development (github#4206)
  ...
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants